3

函数里的this指针

要理解call,apply和bind,那得先知道JavaScript里的this指针。JavaScript里任何函数的执行都有一个上下文(context),也就是JavaScript里经常说到的this指针,例如:

var obj = {
  a: 3,
  foo: function() {
    console.log(this.a);
  }
}

obj.foo();
// 执行结果:打印出a的值3

这里foo的执行context就是obj,即this指针的指向。当然函数也可以直接定义,不加任何容器:

var bar = function() {
  console.log(this.b);
}

bar();  // 等价于global.bar()

如果我们调用bar(),那就相当于调用global.bar(),也就是调用在了一个全局的object上。在不同的环境里这个全局object不一样,在浏览器里是window,在node里则是global,这都是JavaScript执行环境预设的。实际上我们定义的bar函数本身就是定义在了这个global上,即等价于:

global {
  bar: function() {
    console.log(this.b)
  }
}

所以直接执行bar的时候,context当然是global了。这和上面的obj和foo的例子是一样的。

其实context的概念对于任何面向对象的语言都是一样的,比如Java和C++,它们的Class的成员函数在调用的时候第一个参数永远是一个隐式的this指针,然后才是真正的参数。那个this指针必须指向一个具体的这个Class的实例object。

然而Java和C++这样的语言和JavaScript不一样的地方就在于,它们对函数的调用更严格。它们有明确的Class和成员函数的定义,只有Class的实例object才能调用这个Class的成员函数。而JavaScript作为一门函数式编程语言则比较自由,函数的调用可以任意指定上下文context。例如上面的foo和bar,我们可以并不调用在obj和global上,而是自行指定,这就需要用到call和apply。

用call和apply调用函数

JavaScript里用call和apply来指定函数调用的context,即this指针的指向。call和apply都是定义在Function的prototype上,所以任何函数都继承了它们,能直接使用,例如我们定义函数func:

function func(v1, v2) {
  console.log(this.a);

  console.log(v1);
  console.log(v2);
}

然后调用func:

var x = {
  a: 5
}

func.call(x,7, 8);

// 执行结果:
// 打印出x.a的值5
// 打印出v1的值7
// 打印出v2的值8

这里我们用call来调用func函数,call的第一个参数即是要绑定的上下文context,所以函数里的this.a就成了x.a,而x后面传入的参数就是func函数本身的参数v1和v2。

apply的用法也是相似的,只不过apply把x后面的参数组织成一个数组:

func.apply(x,[7, 8]);

这里正因为我们用call和apply指定了func调用的context为x,因此它才可以打印出this.a的值,因为x里定义了a。如果直接调用func(),它就会调用在global上,此时a并没有定义,则会打印出undefined。

bind的用法

有时候我们想指定函数调用的context,但并不想立即调用,而是作为callback传给别人,这在JavaScript里司空见惯,因为到处都会有需要给监听事件绑定callbak函数,这时候call和apply就不能用了,需要用到bind。

var funcBound = func.bind(x);

funcBound(7, 8);
// 等价于:
// func.call(x, 7, 8)

同样是上面的func函数,正如bind的字面意思,这次我们将x绑定到了func上,得到一个新的函数funcBound,此时funcBound的上下文就已经被指定为了x,然后它再直接调用参数7和8,那么效果就相当于之前的func.call(x, 7, 8)了。

bind也可以传入多个参数,此时第一个参数绑定为this,后面的参数则绑定为正常参数,例如:

var funcBound = func.bind(x, 7);

funcBound(8);
// 等价于:
// func.call(x, 7, 8)

与bind作为比较,在JavaScript里为了控制函数的调用者,一种很方便的做法就是用闭包(Closure),因为闭包会保存任何外部的被使用到的变量的reference。不过闭包并没有改变函数执行的上下文,也就是说this指针并没有改变,它能改变的只是函数体里某些具体的变量的指向,这实际上是一种强耦合,需要你在写函数的时候本身就用到外部定义好的一些变量,这是一种不可扩展的函数定义方式。而如果是一个很通用的函数,要想实现this指针的任意绑定,像call和apply那样,则必须用bind。

bind在C++ 11里也有类似的用法,也是C++实现callback的一种主要方式,只不过就像之前说的,C++对类型的检查更严格,绑定的不管是this还是其它参数都必须严格符合这个函数定义的形参的类型。而对于JavaScript,你可以随意给任何函数绑定任何参数,而且这样的绑定经常是隐式的,这也是造成JavaScript里this指针很容易混淆的原因,因为它的函数调用比较自由,而且往往很多时候底层调用的细节我们是不知道的。

实现一个简单的bind

其实我们很容易用call和apply实现一个简单的bind,来加深对bind的理解。这里假定你对JavaScript的Function,Array以及闭包有一定了解。

function myBind() {
  // bind的调用者,即原本的函数。
  var func = this;
  
  // 获取bind的参数列表。
  var args = Array.prototype.slice.call(arguments);
  if (args.length === 0) {
    // 如果未传入任何参数,那就直接返回原本的函数。
    return func;
  }
  
  // 第一个参数是执行上下文context。
  var context = args[0];
  // 后面的参数是正常的调用传参,从第二个参数开始。
  var boundArgs = args.slice(1);

  // 定义bound后的函数,这里用到了Closure。
  var boundFunc = function() {
    // 获取实际调用时传入的参数,并且拼接在之前的boundArgs后面,成为真正的完整参数列表。
    var args = Array.prototype.slice.call(arguments);
    var allArgs = boundArgs.concat(args);
    
    // 这里用apply来调用原始的函数func,执行上下文指向context,并传入完整参数列表。
    return func.apply(context, allArgs);
  }

  // 返回bound函数。
  return boundFunc;
}

这样就完成了一个简单版本的bind,它已经可以实现bind的基本功能,即绑定context和参数列表。具体的原理都在每一步的注释里写出来了,我就不做过多解释了。如果把它加到Function的prototype里,那一个正常的函数例如foo就可以使用foo.myBind(...)来实现bind类似的功能。
-------

有一个有趣的问题,就是如果对一个函数进行多次bind会发生什么事情?会不会context被不断更新?答案是不会,只有第一次bind的context会作为最后调用的实际的this指针。关于这一点,只要对照上面的实现就能理解,不管bind多少次,最里层的apply永远只作用在第一次的传入的context,也就是说原始函数func的调用对象只能是第一次的那个context。

同样,对于一个bind后的函数使用call或者apply,也无法改变它的执行context,原理和上面是一样的。在实际编程中也不会有人这样写,但是初学者有时候可能会不小心犯下这样的错误。实际上我就是初学者。。。也是在这里记录一下我学JavaScript过程中的一些问题和想法。


navi
612 声望189 粉丝

naive